.. _Руководство по настройке SmartInput: #################################### Руководство по настройке SmartInput #################################### ***************************************** Назначение и общий функционал SmartInput ***************************************** **SmartInput** - компонент быстрого ввода данных. Позволяет в текстовом поле создавать описание и заполнять дополнительные атрибуты через специальные теги. Компонент использует конфигурацию, указанную в соответствующем внешнем скрипте (файл формата .js), который находится в реестре скриптов. Параметры подключения внешних скриптов передаются в настройках компонента. Подробнее о настройке компонента (виджета) на Домашней страницы см. :ref:`Руководство по настройке Домашней страницы`. ******************************************************************** Пример настройки функционала SmartInput на странице Новостной ленты ******************************************************************** К компоненту подключается несколько внешних скриптов: * MAIN. * OPERATORS. * TOOLBAR. * SUBMIT. * VALIDATION. ======================================== Назначение конфигурационного файла MAIN ======================================== Это основной конфигурационный файл, он содержит перечень типов элементов (тегов), например, контактов, местоположений и т.п., с которыми работает компонент SmartInput, и методы для получения и обработки данных этих элементов (добавление в SmartInput, вставка текста и т.п.). В конце файла "MAIN" находится структура "MAIN_CONFIG", которая считывается компонентом SmartInput.vue. В ней перечислены все типы элементов (теги) и методы, используемые для получения и обработки данных этих элементов. .. note:: Сами методы описываются в начале файла "MAIN", затем в файле "MAIN" описывается структура "MAIN_CONFIG", в которой этим методы только перечисляются. Пример структуры "MAIN_CONFIG" для одного из типов элементов (типа элемента "Контакт"): :: ... MAIN_CONFIG = { // Перечень типов данных (тегов) ITEM_TYPES: { // тип - контакт contact:{ // Метод получения данных контакта source: getUserList, // Метод добавления выбранного результата (из меню или диалогового окна) в связанные данные SmartInput callback: addUser, // Метод вставки текста выбранного результата в поле редактора (поле для ввода текста) pasteToInput: pasteUserName, // Метод для изменения какого-либо свойства контакта в связанных данных (в текущей реализации Системы используется для смены статуса с клиента на заказчика) updateProperty:updateUser, // Метод проверки возможности удаления из связанных данных (в текущей реализации Системы используется для блокировки удаления единственного заказчика в запросе) removalCheck: checkUserRemoval }, } } ... Все другие типы элементов (и используемые ими методы) указываются в блоке "ITEM_TYPES" аналогично. Пример настроек: :: function MAIN_CONFIG($context) { // ! rename to main.js // this is also included in submitHandler.js const getCurrentUser = function () { try { return $context.$repos.schemaRepository('people').getMetadata().userInfo['ITSM']['ID'] } catch (e) { return undefined } } // users (contacts) const getUserList = async function ({ str }) { const resp = await $context.$repos.schemaRepository('people').list({ fields: ['full_name', 'internet_e-mail', 'phone_number_business', 'username'], offset: 0, maxRows: 10, clauses: [ { operator: 'and', field: 'full_name', operand: 'contains', value: str }, { operator: 'or', field: 'internet_e-mail', operand: 'contains', value: str }, { operator: 'or', field: 'phone_number_business', operand: 'contains', value: str } ] }) return resp } const addUser = function ({ $selectedData, $operator, $item }) { let newValue = '' let newId = '' let inSelected = $selectedData.filter(item => item.label === 'contact') for (let key in $item) { if (key !== 'id') { newValue = $item[key] newId = $item.id break } } if (inSelected.length) { // if ( inSelected.length === 1) { $selectedData.push({ label: 'contact', value: newValue, id: newId, // ! weird logic - would work with max 2 persons only // ? which should be distinct contact or customer ??? personStatus: inSelected[0].personStatus === 'contact' ? 'customer' : 'contact' }) // } } else { $selectedData.push({ label: 'contact', value: newValue, id: newId, personStatus: 'contact' === 'contact' ? 'customer' : '' }) } } const pasteUserName = function (userData) { return userData["full_name"] } const updateUser = function ({ $selectedData, $itemUpdate }) { if ($itemUpdate.property === 'personStatus') { // change user personStatus (from contact to customer and vise-versa) // disallow unselecting customer if ($itemUpdate.item.personStatus === 'customer') return 'NewsFeed_SmartInput_ErrUnselectCustomer' // change status to customer and drop previous customer status let persons = $selectedData.filter(item => item.label === 'contact') persons.forEach(p => { if (p.personStatus === 'customer') { p.personStatus = 'contact' } if (p.id === $itemUpdate.item.id) { p.personStatus = 'customer' } }) return true } } const checkUserRemoval = function ({ $selectedData, $item, $index }) { let customer = $selectedData.find(item => item.label === 'contact' && item.personStatus === 'customer') return (customer && customer.id && customer.id === $item.id) ? 'NewsFeed_SmartInput_ErrDelCustomer' : true } // hashtags (kb and others) const getHashTags = async function ({ str }) { const resp = await $context.$repos.schemaRepository('kb').list({ fields: ['articletitle'], offset: 0, maxRows: 10, clauses: [ { operator: 'and', field: 'articletitle', operand: 'contains', value: str }, { operator: 'or', field: 'article_keywords', operand: 'contains', value: str } ] }) return resp } const addTag = function ({ $selectedData, $operator, $item }) { // * keep all logic for this item type here // e.g. max amount of same type, item overwrite and other conditions $selectedData.push({ label: $operator, value: $item }) } const pasteTag = function (tagData) { return tagData["articletitle"] } // templates (catalog) const getTemplates = async function ({ str }) { try { let cat = [] if ($context.$getters.currentCatalog.length) { cat = $context.$getters.currentCatalog } else { cat = await $context.$repos.userApps.catalog.get() $context.$repos.userApps.catalog.set(cat) // this.$store.dispatch('app/setCatalog', cat) } let result = { items: [] } if (cat.length) { cat.forEach(item => { if (item.items && item.items.length) { item.items.forEach(innerItem => { let comp1 = innerItem.name && (innerItem.name.toLowerCase().indexOf(str.toLowerCase()) !== -1) let comp2 = innerItem.keywords && (innerItem.keywords.toLowerCase().indexOf(str.toLowerCase()) !== -1) if (comp1 || comp2) { result.items.push({ id: innerItem.id, name: innerItem.name, form_id: innerItem.form_id }) } }) } }) } return result.items.slice(0, 10) } catch (err) { throw err } } const addTemplate = function ({ $selectedData, $operator, $item }) { let newValue = '' let newId = '' let inSelected = $selectedData.filter(item => item.label === 'template') for (let key in $item) { if (key !== 'id') { newValue = $item[key] newId = $item.id break } } if (inSelected.length) { inSelected[0].value = newValue inSelected[0].id = newId } else { $selectedData.push({ label: 'template', value: newValue, id: newId, form_id: $item["form_id"], }) } } const pasteTemplate = function (templateData) { return templateData["name"] } // locations const getLocations = async function ({ str, $linkedData }) { try { if (!str) { const persons = $linkedData.filter(item => item.label === 'person') if (!persons.length) { persons.push({ id: getCurrentUser, label: 'person', personStatus: 'customer', value: '' }) } const resp = await $context.$repos.schemaRepository('people').list({ field: 'id', distinct: true, value: persons.map(item => item.id), fields: ['site_country', 'site_city', 'site_street', 'site_zip_postal_code', 'site_id'] }) return resp.map(item => { return { country: item.site_country, city: item.site_city, street: item.site_street, zip_postal_code: item.zip_postal_code, id: item.site_id, selected: false } }) } else { const resp = await $context.$repos.schemaRepository('site').list({ fields: ['country', 'city', 'street', 'zip_postal_code', 'id'], offset: 0, clauses: [ { operator: 'and', field: 'country', operand: 'contains', value: str }, { operator: 'or', field: 'city', operand: 'contains', value: str }, { operator: 'or', field: 'street', operand: 'contains', value: str } ] }) return resp.map(item => { item.selected = false return item }) } } catch (err) { throw err } } const addLocation = function ({ $selectedData, $operator, $item }) { let inSelected = $selectedData.filter(item => item.label === 'location') let addressString = `${$item.country ? $item.country : ''} ${$item.city ? $item.city : ''} ${$item.street ? $item.street : ''}` if (inSelected.length) { inSelected[0].value = addressString inSelected[0].id = $item.id } else { $selectedData.push({ label: 'location', value: addressString, id: $item.id }) } } // used in `getCi()` const setUniqueMatches = function (arr) { let matchedCis = [] let obj = {} arr.forEach(item => { if (item.reconciliationidentity in obj) return obj[item.reconciliationidentity] = 1 matchedCis.push({ type: item.type, name: item.name, model: item.model, manufacturer: item.manufacturername, people_id: item.peoplegroup_form_entry_id, ci_id: item.reconciliationidentity, selected: false }) }) return matchedCis } const getCi = async function ({ str, $linkedData }) { try { if (!str) { const persons = $linkedData.filter(item => item.label === 'person') if (!persons.length) { persons.push({ id: getCurrentUser, label: 'person', personStatus: 'customer', value: '' }) } const resp = await $context.$repos.schemaRepository('ci_people_assoc').list({ fields: [ 'peoplegroup_form_entry_id', 'manufacturername', 'type', 'model', 'name', 'reconciliationidentity' ], field: 'id', distinct: true, value: persons.map(item => item.id) }) return setUniqueMatches(resp) // * displays contact names above search field // this.personNames = '' // persons.forEach(item => { // this.personNames += item.value + ' ' // }) } else { const resp = await $context.$repos.schemaRepository('ci_people_assoc').list({ fields: [ 'peoplegroup_form_entry_id', 'manufacturername', 'type', 'name', 'model', 'reconciliationidentity' ], offset: 0, clauses: [ { operator: 'and', field: 'type', operand: 'contains', value: str }, { operator: 'or', field: 'model', operand: 'contains', value: str }, { operator: 'or', field: 'name', operand: 'contains', value: str } ] }) return setUniqueMatches(resp) } } catch (err) { throw err } } const addCi = function ({ $selectedData, $operator, $item }) { let inSelected = $selectedData.filter(item => item.label === 'ci') let descString = `${$item.type ? $item.type : ''} ${$item.name ? $item.name : ''} ${$item.model ? $item.model : ''} ${$item.manufacturer ? $item.manufacturer : ''}` if (inSelected.length) { inSelected[0].value = descString inSelected[0].id = $item.id } else { $selectedData.push({ label: 'ci', value: descString, id: $item.ci_id }) } } function getResultItems(operators = {}) { let result_operators = {} Object.keys(operators).forEach(operator_key=>{ Object.assign(result_operators, {[operator_key]:{}}) Object.keys(operators[operator_key]).forEach(method_key=>{ if(method_key!='key') { if(operators[operator_key][method_key].length>0) result_operators[operator_key][method_key] = eval(operators[operator_key][method_key]) } }) }) return { ITEM_TYPES: { contact: { source: getUserList, callback: addUser, pasteToInput: pasteUserName, updateProperty: updateUser, removalCheck: checkUserRemoval }, hashtag: { source: getHashTags, callback: addTag, pasteToInput: pasteTag, }, template: { source: getTemplates, callback: addTemplate, pasteToInput: pasteTemplate, }, location: { source: getLocations, callback: addLocation, }, ci: { source: getCi, callback: addCi, }, attachment: {}, ...result_operators }, // use this form ID if templates isn't provided DEFAULT_FORM_ID: 'schm001_form_0001', // * simple creation mode // attempt to create entry on submit and DO NOT redirect to that entry result page simpleMode: true } } return getResultItems($context.customOperators) } .. ============================================= Назначение конфигурационного файла OPERATORS ============================================= В файле "OPERATORS" указываются специальные символы для ввода тегов. Например на странице Новостной ленты компонент SmartInput.vue использует следующие тэги: * @ -контакт (contact). * # - запись базы знаний (hashtag). * ! - шаблон (template). Пример настроек: :: function OPERATORS_CONFIG({customOperators}) { return { OPERATORS: { contact: { key: '@', }, hashtag: { key: '#', }, template: { key: '!', }, ...customOperators } } } .. =========================================== Назначение конфигурационного файла TOOLBAR =========================================== Файл "TOOLBAR" содержит конфигурацию, которая описывает тулбар (панель инструментов). Здесь перечисляются настройки каждого элемента тулбара (definitions) и их порядок (toolbar). Свойства элементов тулбара аналогичны одноименным свойствам компонента "QEditor" в "Quasar Framework". В файле "TOOLBAR" для каждого элемента тулбара (кнопки) нужно указать метод обработки клика в формате (в текущей реализации Системы этот метод одинаков для всех элементов тулбара): :: ... const handleTagClick = function (){ $vm.operatorCallback({type:'hashtag'}) } ... Здесь в параметре "type" указывается название оператора (оно должно совпадать с его названием в конфигурационном файле "OPERATORS"). При клике по элементу тулбара выполняется запрос данных по этому типу (значению, указанному в параметре "type") и отображается список/диалоговое окно с результатами для выбора. Привязка типа к компоненту (списку/диалоговому окну) в текущей реализации Системы жестко прописала в компоненте SmartInput.vue (без возможности настройки). В текущей реализации Системы по клику на элемент тулбара реализовано отображение только простых списков. В дальнейшем при клике на элемент тулбара можно будет настроить выбор метроположения на карте, фильтрацию, быструю вставку связанных данных и т.п. Пример настроек: :: function toolbar_config({ icons, layout, customDefinitions }) { return function ({ $vm }) { const toolbarItemHandler = function (mouseEvent, Proxy, Caret) { // mouseEvent - normal mouse event // Proxy - handler, target ??? // Caret - cursor in editor (HTML, vue component and range) } const handleContactClick = function () { $vm.operatorCallback({ type: 'contact' }) } const handleTagClick = function () { $vm.operatorCallback({ type: 'hashtag' }) } const handleTemplateClick = function () { $vm.operatorCallback({ type: 'template' }) } const handleLocationClick = function () { $vm.operatorCallback({ type: 'location' }) } const handleCiClick = function () { $vm.operatorCallback({ type: 'ci' }) } const handleAttachmentClick = function () { // ? use q-file } const submitData = function () { $vm.submitData() } const DEFAULT_ICONS = { hashtag: 'book2', location: 'book' } function setDefinitions({ icons = DEFAULT_ICONS }) { if(customDefinitions) { Object.keys(customDefinitions).forEach(def_key=>{ if(customDefinitions[def_key]!==undefined) customDefinitions[def_key].handler = eval(customDefinitions[def_key].handler) }) } return { location: { icon: 'location_on', handler: handleLocationClick, type: 'no-state' }, ci: { icon: 'storage', handler: handleCiClick, type: 'no-state' }, submit: { handler: submitData, type: 'no-state' }, attachment: { icon: icons.attachment || DEFAULT_ICONS.attachment, handler: handleAttachmentClick, type: 'no-state' }, hashtag: { icon: icons.hashtag || DEFAULT_ICONS.hashtag, handler: handleTagClick, type: 'no-state' }, contact: { icon: icons.contact || DEFAULT_ICONS.contact, handler: handleContactClick, type: 'no-state' }, template: { icon: icons.template || DEFAULT_ICONS.template, handler: handleTemplateClick, type: 'no-state' }, ...customDefinitions } } const DEFAULT_LAYOUT = [['location', 'ci'], ['attachment', 'hashtag', 'contact', 'template'], ['submit'], ['new_button']] function setToolbarLayout({ layout }) { return layout || DEFAULT_LAYOUT } return { definitions: setDefinitions({ icons }), // toolbar btn layout toolbar: setToolbarLayout({ layout }) } } } .. ========================================== Назначение конфигурационного файла SUBMIT ========================================== В файле "SUBMIT" описывается метод для кнопки "Отправить" на панели инструментов. Метод описывает формирование данных создаваемой формы из связаных данных и текста, введенного в поле ввода. Связанные данные и текст, введенный в поле ввода, перед открытием создаваемой формы должны быть отформатированы. В файле "SUBMIT" отписываются правила форматирования этих данных. В конце файла "SUBMIT" должен быть указан метод обработки данных формы в формате "submit:methodName". Например: :: ... smartInputSubmitter = { submit: createNewEvent, } ... Здесь: "createNewElement" - название метода формирования данных формы из этого же скрипта. Данный метод (в примере это метод "createNewElement") должен вернуть структуру формата: :: ... { // данные формы в формате "имя_поля": "значение" data:{...}, // опционально: идентификатор формы form_id: "FORM_ID" } ... При отсутствии идентификатора формы выбирается значение, указанное в свойстве "DEFAULT_FORM_ID" в конфигугационном файле "MAIN". Пример настроек: :: function smartInputSubmitter($context) { const getCurrentUser = function () { try { return $context.$repos.schemaRepository('people').getMetadata().userInfo['ITSM']['ID'] } catch (e) { return undefined } } const createNewEvent = async function (description, links) { try { let persons = links.filter(item => item.label === 'contact') let templates = links.filter(item => item.label === 'template') let locations = links.filter(item => item.label === 'location') let cis = links.filter(item => item.label === 'ci') if (!persons.length) { persons.push({ personStatus: 'customer', id: getCurrentUser }) } if (persons.length === 1) { if (persons[0].personStatus === 'customer') { persons.push({ personStatus: 'contact', id: persons[0].id }) } else { persons.push({ personStatus: 'customer', id: getCurrentUser }) } } if (templates.length) { let query = {} persons.forEach((item, i) => { query[item.personStatus] = item.id }) query.description = description if (locations.length) query.location = locations[0].id if (cis.length) query.ci = cis[0].id return { form_id: templates[0].form_id, data: query } } else { return await createNewImcident({ persons, locations, cis, description, links }) } } catch (e) { throw e return e } } const createNewIncident = async function ({ persons, locations, cis, description, links }) { try { let requestObj = { // Для корректного заполнения на backend "incident_number": "$NULL$", // "contact_company": "$NULL$", // "priority": "$NULL$", // могут быть изменены данными из шаблона "impact": "4000", "urgency": "4000", "priority": "3", "service_type": "0", // Точно известные на текущий момент данные "description": description, } // ? personData is not used let personData = links.filter(item => item.label === "person") let customer = persons.filter(item => item.personStatus === 'customer')[0] let resp = await $context.$repos.schemaRepository('people').list({ field: 'id', value: customer.id, fields: ['company', 'phone_number_business'] }) requestObj.person_id = customer.id requestObj.customer = customer.value requestObj.company = resp[0].company requestObj.contact_company = resp[0].company requestObj.phone_number = resp[0].phone_number_business requestObj.description = description if (locations.length) requestObj.site_id = locations[0].id if (cis.length) requestObj.hpd_ci_reconid = cis[0].id // resp = await $context.$repos.schemaRepository('incidents').create(requestObj) return { // ! exclude `form_id` - component will read default value from config data: requestObj } } catch (e) { throw e } } return { submit: createNewEvent, } } .. ============================================== Назначение конфигурационного файла VALIDATION ============================================== В файле "VALIDATION" описываются правила валидации связаных данных. Для компонента SmartInput.vue на странице Новостной ленты используется, например, следующиая валидация: проверяется количество контактов (максимум два), количество шаблонов и местоположений. Формат валидации (правила валидации) такой же, как у валидации полей в "Quasar Framework". Пример настроек: :: function VALIDATION_RULES($context) { const maxTemplatesAllowed = function (links) { return links.filter(item => item.label === 'template').length <= 1 || 'NewsFeed_SmartInput_TemplatesMax' } const maxContactsAllowed = function (links) { return links.filter(item => item.label === 'contact').length <= 2 || 'NewsFeed_SmartInput_ContactsMax' } const maxPlacesAllowed = function (links) { return links.filter(item => item.label === 'location').length <= 1 || 'NewsFeed_SmartInput_LocationsMax' } const mustBeOnlyOneClient = function (links) { return links.filter(item => item.label === 'contact').filter(item => item.personStatus === 'customer').length === 1 || 'NewsFeed_SmartInput_MustBeOnlyOneClient' } return [ maxTemplatesAllowed, maxContactsAllowed, maxPlacesAllowed, mustBeOnlyOneClient, ] } ..